Skip to content

refactor(workspace): timeline API overhaul and extension system cleanup#1528

Merged
braden-w merged 65 commits intomainfrom
opencode/calm-comet
Mar 18, 2026
Merged

refactor(workspace): timeline API overhaul and extension system cleanup#1528
braden-w merged 65 commits intomainfrom
opencode/calm-comet

Conversation

@braden-w
Copy link
Member

@braden-w braden-w commented Mar 13, 2026

Overhauls the Timeline from a bag of standalone functions into a cohesive object API, and cleans up the extension system's dual-scope registration model. What started as a snapshot restore bug led to pulling on threads until the whole timeline module was restructured.

Why the timeline needed this

The old timeline had two problems. First, DocumentHandle was a wrapper around Timeline that added nothing—consumers had to go through the handle to reach timeline methods, but the handle's only job was forwarding calls. Second, the public API surface was too wide: readEntry, pushText, pushRichtext, pushSheet, parseSheetFromCsv, restoreFromSnapshot were all standalone exports that leaked implementation details.

The snapshot restore bug was the catalyst. Server-side applySnapshot() was a silent no-op (CRDT idempotency means applying an old state to a newer doc changes nothing), and the richtext path destroyed formatting by extracting text-only content from XmlFragment. Fixing this properly required restoreFromSnapshot to become a method that understood the timeline's internal state—which meant the standalone-function architecture had to go.

BEFORE                                    AFTER
┌──────────────┐                          ┌──────────────────────────┐
│ DocumentHandle│──wraps──→ Timeline       │ Timeline                 │
│  .read()     │           .readEntry()   │  .read()          method │
│  .write()    │           .pushText()    │  .write()         method │
│  .asText()   │           .pushSheet()   │  .writeSheet()    method │
│  .extensions │           .pushRichtext()│  .appendText()    method │
└──────────────┘           (standalone)   │  .asText/Rich/Sheet()   │
                                          │  .currentEntry    getter │
                                          │  .observe()       method │
                                          │  .restoreFromSnapshot()  │
                                          └──────────────────────────┘
                                          DocumentHandle = Timeline
                                          (type alias, no wrapper)

Mode-aware write

The old API had writeText() for text content. Now write() is mode-aware—it checks the current entry type and either replaces in-place (same type, no new entry, observe() doesn't fire) or pushes a new entry (type change, observe() fires). writeSheet() follows the same pattern:

// Before
handle.writeText('hello');

// After — write() figures out what to do
handle.write('hello');          // text → text: replaces in-place
handle.write('hello');          // sheet → text: pushes new text entry
handle.writeSheet('A,B\n1,2'); // same pattern for sheets

Snapshot restore

Moved entirely to client-side. The key fix for richtext: deep-clone the XmlFragment tree instead of extracting text.

Snapshot type Same type in live doc? Behavior
Text Yes In-place replace (delete all + insert)
Text No Push new text entry
Sheet Any Push new sheet entry from CSV
Richtext Any Push new richtext entry with deep-cloned XML tree

Extension system: dual-scope registration

Extensions that work on both the workspace Y.Doc and content document Y.Docs (persistence, sync) previously had no type-safe way to register once for both scopes. The factory signature for workspace extensions (ExtensionContext with tables, kv, awareness) is wider than for document extensions (DocumentContext with timeline, ydoc).

SharedExtensionContext is the intersection—the fields available in both scopes:

┌──────────────────────────┐    ┌──────────────────────────────┐
│  ExtensionContext         │    │  DocumentContext              │
│  (workspace scope)        │    │  (document scope)             │
│                           │    │                               │
│  id, ydoc, tables, kv,   │    │  id, ydoc, timeline,          │
│  awareness, definitions,  │    │  extensions, whenReady        │
│  extensions, whenReady    │    │                               │
└─────────────┬────────────┘    └──────────────┬───────────────┘
              │                                 │
              └────────────┬────────────────────┘
                           ▼
              SharedExtensionContext
              Pick<ExtensionContext, 'ydoc' | 'whenReady'>

withExtension() becomes thin sugar—it registers the same factory for both scopes, constrained to SharedExtensionContext. If you need workspace-only or document-only fields, use the scoped methods:

// Dual-scope: factory only sees { ydoc, whenReady }
createWorkspace(definition)
  .withExtension('persistence', ({ ydoc }) => { ... })

// Scoped: full context for each scope
  .withWorkspaceExtension('analytics', ({ tables, kv }) => { ... })
  .withDocumentExtension('sync', ({ ydoc, timeline }) => { ... })

The sync extension now returns scoped factories:

const sync = createSyncExtension({ url: (id) => `ws://.../${id}` });

createWorkspace(definition)
  .withExtension('persistence', indexeddbPersistence)
  .withWorkspaceExtension('sync', sync.workspace)
  .withDocumentExtension('sync', sync.document);

Other extension changes

timeline was added to DocumentContext so document extension factories can bind to the content timeline directly. LIFO teardown logic (destroyLifo/startDestroyLifo) was extracted to lifecycle.ts as shared primitives used by both createWorkspace and createDocuments. The lifecycle.ts module docstring was rewritten—it still described a stale "Providers vs Extensions" architecture with a nonexistent ProviderFactory type.

DOCUMENTS_ORIGIN is now exported from @epicenter/workspace. It's a sentinel symbol for distinguishing auto-bumps (document edit → row updatedAt update) from user-initiated row changes in table.observe() callbacks.

Smaller changes

table.delete() returns void instead of DeleteResult. The discriminated union added no value—Y.Map.delete() is fire-and-forget, and callers never branched on the result. table.observe() callbacks now receive typed TransactionMeta (with origin) instead of raw unknown.

~30 skill files got metadata blocks, cross-reference callouts, and "When to Apply" sections. A refactoring methodology skill was added.

Loading
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant